20 Web 端连接 ROS2 与话题订阅
Web 端连接 ROS2 与话题订阅
关联:索引
要解决的问题
- Web 端已经会用 WebSocket 了,为什么还需要 rosbridge 才能“订阅 ROS2 话题”
- Web 端连上
ws://<host>:9090后,第一条该发什么 JSON 才能订阅话题 - rosbridge 推过来的 JSON 长什么样,哪些字段是“协议层”,哪些字段才是“业务数据”
- ROS2 的消息类型字符串(例如
std_msgs/msg/String)在 Web 侧怎么表达,什么时候必须写type - 不同话题消息结构差异很大,Web 端如何做“解析 + 校验 + 转换”为可用的数据结构
章节内容(本讲核心):
- Web 端通过 rosbridge 连接 ROS2(WebSocket 连接与连接参数)
- 实现话题订阅(rosbridge JSON 协议:subscribe / unsubscribe)
- JSON 数据解析(安全解析、字段校验、容错与日志)
- ROS2 消息格式转换(把
msg转为 TypeScript 结构,并转换成页面可用数据)
与前置知识衔接(避免重复):
- 已学:WebSocket 生命周期事件与状态机、JSON 统一格式与解析容错(见 WebSocket 客户端)
- 已学:rosbridge 原理与环境部署、9090 端口与最小可用验证(见 rosbridge 部署)
- 本讲不重复:rosbridge 的安装启动细节、wscat/websocat 工具验证流程(默认已完成)
- 本讲定位:把“WebSocket 客户端能力”迁移到“rosbridge 协议”,在 Vue3 + TS 项目里完成可复用的连接与订阅,并能打印/展示订阅数据
独立项目创建与代码落位(本讲建议)
本讲的代码可以组成一个“独立可运行”的前端项目。推荐使用 Vue3 + Vite + TypeScript 模板,便于直接把中的 TypeScript 代码放进工程中运行与调试。
1) 创建项目(在你的工作目录执行)
npm create vite@latest 07_rosbridge_topic_subscriber -- --template vue-ts
cd 07_rosbridge_topic_subscriber
npm install
npm run dev
npm create vite@latest ... -- --template vue-ts:用 Vite 创建 Vue3 + TS 项目骨架(包含src/main.ts与src/App.vue)。npm install:安装依赖(首次创建必须执行一次)。npm run dev:启动开发服务器;浏览器打开终端提示的本地地址即可看到页面。
Windows(PowerShell)常见提示:如果遇到 “禁止运行脚本 npm.ps1”,优先用 npm.cmd 执行同等命令:
npm.cmd install
npm.cmd run dev
- 原因:PowerShell 的执行策略可能禁止运行
npm.ps1;npm.cmd不受该限制。
2) 推荐项目结构(相对路径)
07_rosbridge_topic_subscriber/
src/
main.ts
App.vue
components/
RosbridgeMinimalSubscribe.vue
RosbridgeWorkshop.vue
utils/
rosbridge.ts
ros-msg-convert.ts
3) 代码应该放到哪里(相对路径)
- 放置位置:
src/components/RosbridgeMinimalSubscribe.vue - 放置方式:把中的“最小订阅代码”放进
<script setup lang="ts">,由页面按钮触发connect()创建连接,并在onUnmounted时关闭连接(避免热更新/切换页面造成重复连接)。 - 放置位置:
src/utils/rosbridge.ts - 放置方式:将
RosbridgeClient、RosbridgeSubscribe/RosbridgeUnsubscribe等类型与函数完整粘贴到该文件中。 - 放置位置:
src/utils/ros-msg-convert.ts - 放置方式:把消息结构体与转换函数完整粘贴到该文件中,供组件调用。
- 将页面跑起来(把组件挂到 App 上):
- 放置位置:
src/App.vue
4) 最小接线示例(确保项目一跑就能联调)
把 src/App.vue 改成只渲染一个演示组件(最简单、最不依赖 Router/Pinia):
<template>
<RosbridgeMinimalSubscribe />
</template>
<script setup lang="ts">
import RosbridgeMinimalSubscribe from './components/RosbridgeMinimalSubscribe.vue'
</script>
<template>
<RosbridgeWorkshop />
</template>
<script setup lang="ts">
import RosbridgeWorkshop from './components/RosbridgeWorkshop.vue'
</script>
<template>
<div class="wrap">
<h2>rosbridge 话题订阅(最小示例)</h2>
<div class="row">
<label class="label" for="url">WebSocket URL</label>
<input id="url" v-model="url" class="input" type="text" />
</div>
<div class="row">
<div class="status">状态:{{ status }}</div>
<button class="btn" type="button" :disabled="status === 'OPEN' || status === 'CONNECTING'" @click="connect">
连接
</button>
<button class="btn" type="button" :disabled="status !== 'OPEN'" @click="disconnect">
断开
</button>
</div>
<div class="row">
<div class="status">订阅:{{ topic }}({{ type }})</div>
</div>
<div class="row">
<div class="status">最近一条 /chatter:{{ chatterText ?? '(暂无)' }}</div>
</div>
<div v-if="lastRaw" class="row">
<details>
<summary>最近一条原始 JSON</summary>
<pre class="pre">{{ lastRaw }}</pre>
</details>
</div>
<div v-if="lastError" class="row error">
错误:{{ lastError }}
</div>
</div>
</template>
<script setup lang="ts">
import { onUnmounted, ref } from 'vue'
const url = ref('ws://localhost:9090')
type Status = 'IDLE' | 'CONNECTING' | 'OPEN' | 'CLOSING' | 'CLOSED'
const status = ref<Status>('IDLE')
const topic = '/chatter'
const type = 'std_msgs/msg/String'
const id = 'sub-chatter'
const chatterText = ref<string | null>(null)
const lastRaw = ref<string>('')
const lastError = ref<string>('')
let ws: WebSocket | null = null
function setStatusFromReadyState(sock: WebSocket): void {
status.value =
sock.readyState === WebSocket.CONNECTING
? 'CONNECTING'
: sock.readyState === WebSocket.OPEN
? 'OPEN'
: sock.readyState === WebSocket.CLOSING
? 'CLOSING'
: 'CLOSED'
}
function connect(): void {
lastError.value = ''
chatterText.value = null
lastRaw.value = ''
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
setStatusFromReadyState(ws)
return
}
ws = new WebSocket(url.value)
setStatusFromReadyState(ws)
ws.addEventListener('open', () => {
if (!ws) return
setStatusFromReadyState(ws)
const subscribeMsg = { op: 'subscribe', topic, type, id }
ws.send(JSON.stringify(subscribeMsg))
})
ws.addEventListener('message', (ev) => {
const raw = typeof ev.data === 'string' ? ev.data : ''
if (!raw) return
lastRaw.value = raw
let data: unknown
try {
data = JSON.parse(raw)
} catch {
return
}
if (!data || typeof data !== 'object') return
if (!('op' in data) || !('topic' in data) || !('msg' in data)) return
const d = data as { op: unknown; topic: unknown; msg: unknown }
if (d.op !== 'publish' || d.topic !== topic) return
const msg = d.msg as { data?: unknown }
chatterText.value = typeof msg.data === 'string' ? msg.data : JSON.stringify(d.msg)
})
ws.addEventListener('error', () => {
lastError.value = 'WebSocket error'
})
ws.addEventListener('close', (ev) => {
status.value = 'CLOSED'
lastError.value = lastError.value || `closed: ${ev.code} ${ev.reason || ''}`.trim()
})
}
function disconnect(): void {
if (!ws) return
try {
status.value = 'CLOSING'
ws.close()
} finally {
ws = null
}
})
onUnmounted(() => {
disconnect()
})
</script>
<style scoped>
.wrap {
max-width: 860px;
margin: 24px auto;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 12px;
}
.row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.label {
width: 120px;
}
.input {
flex: 1;
padding: 8px 10px;
border: 1px solid #d1d5db;
border-radius: 8px;
}
.status {
flex: 1;
}
.btn {
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
}
.pre {
margin-top: 8px;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: auto;
}
.error {
color: #b91c1c;
}
</style>
-
onUnmounted:组件销毁时关闭连接,避免切换/热更新导致后台残留连接继续占用订阅。 -
connect():建立 WebSocket 连接,并在open后发送op=subscribe。 -
message:只处理op=publish且topic=/chatter的推送,并把msg.data显示出来。 -
AI 生成 rosbridge 的 connect/subscribe/unsubscribe TypeScript 封装代码
-
AI 生成某个具体消息类型(例如 LaserScan/Odometry)的 TS 接口与转换函数
作业:
- WebSocket 连接成功:浏览器触发
open,且能看到 “connected” 日志 - 订阅请求发出:成功
send了一条op=subscribe的 JSON - 收到话题数据:浏览器
message中能看到topic与msg - 打印关键字段:从
msg中提取至少 1 个核心字段并打印/展示
1. 订阅请求(subscribe)最小结构
{
"op": "subscribe",
"topic": "/chatter",
"type": "std_msgs/msg/String",
"id": "sub-chatter"
}
op:操作类型,订阅必须是subscribetopic:要订阅的 ROS2 话题名(以/开头)id:订阅 id(用于后续取消订阅、排错与日志追踪)
2. 服务端推送的数据结构(你在 onmessage 会收到)
{
"op": "publish",
"topic": "/chatter",
"msg": {
"data": "hello world"
}
}
op=publish:表示“服务端在推送某个 topic 的消息”,不是让你去发布topic:这条消息来自哪个话题(用于路由到不同处理函数)msg:真正的 ROS2 消息内容(字段名与 ROS2 消息定义一致)
3. 取消订阅(unsubscribe)
{
"op": "unsubscribe",
"topic": "/chatter",
"id": "sub-chatter"
}
- 最关键:
id必须与订阅时一致,否则你很难确认取消了哪一个订阅
1. 快速启动一个持续发布的话题(示例:/chatter)
在 ROS2 终端执行(任选其一):
ros2 run demo_nodes_cpp talker
- 作用:启动 ROS2 示例 talker,会持续发布
/chatter(字符串消息) - 预期:终端持续输出发布日志,且命令不退出
或(更可控的发布频率):
ros2 topic pub -r 5 /chatter std_msgs/msg/String "{data: 'hello from ros2'}"
-r 5:每秒 5 次发布/chatter:话题名std_msgs/msg/String:消息类型{data: '...'}:消息体,字段名必须与消息定义一致
2. 自检:确认话题确实在跑
ros2 topic list
- 作用:列出当前 ROS2 Graph 中存在的话题
- 预期:能看到
/chatter
ros2 topic echo /chatter --once
- 作用:只打印一条消息,用于确认消息结构(字段名)是否符合预期
- 预期:输出类似
data: ...
本节先用“纯 WebSocket + JSON”打通闭环,不依赖额外库,便于你理解协议本质。
1. 最小 TypeScript 示例(可直接放到任意 Vue3 组件的 <script setup lang="ts"> 中执行)
const url = 'ws://localhost:9090'
const ws = new WebSocket(url)
ws.addEventListener('open', () => {
console.log('[rosbridge] connected:', url)
const subscribeMsg = {
op: 'subscribe',
topic: '/chatter',
type: 'std_msgs/msg/String',
id: 'sub-chatter'
}
ws.send(JSON.stringify(subscribeMsg))
console.log('[rosbridge] subscribe sent:', subscribeMsg)
})
ws.addEventListener('message', (ev) => {
const raw = typeof ev.data === 'string' ? ev.data : ''
if (!raw) return
let data: unknown
try {
data = JSON.parse(raw)
} catch {
console.warn('[rosbridge] invalid json:', raw)
return
}
if (
typeof data === 'object' &&
data !== null &&
'op' in data &&
'topic' in data &&
'msg' in data
) {
const d = data as { op: unknown; topic: unknown; msg: unknown }
if (d.op === 'publish' && d.topic === '/chatter') {
const msg = d.msg as { data?: unknown }
console.log('[topic:/chatter]', msg.data)
}
}
})
ws.addEventListener('error', () => {
console.error('[rosbridge] ws error')
})
ws.addEventListener('close', (ev) => {
console.warn('[rosbridge] closed:', ev.code, ev.reason)
})
new WebSocket(url):连接 rosbridge 的 WebSocket 服务(默认 9090)open:连接建立后立刻发送订阅 JSON(第一条最关键的主动操作)ws.send(JSON.stringify(...)):rosbridge 只认识 JSON 文本;不要直接传对象message:收到推送后先做 JSON.parse,再做字段存在性判断,最后按topic路由处理d.op === 'publish':表示这是订阅数据推送(不是你要发布)msg.data:std_msgs/msg/String的字段名就是data,这来自 ROS2 消息定义
五、练习(至少 2 题)
- 把订阅从
/chatter改为你环境里的另一个话题,并打印其中一个关键字段(例如电压、电流、温度、状态码)。 - 写出一段“解析失败的容错策略”:当 JSON 解析失败或字段缺失时,你要打印什么信息,才能最快定位问题?
七、学生任务(提交物与标准)
- 提交物:1 段最小订阅代码(可截图/可复制)、Console 输出截图(至少 3 条连续消息)、你订阅的话题名与消息类型
- 标准:别人按你的 ws 地址与 topic/type 能复现收到消息
八、大模型任务(给 AI 的指令模板 + 校验点)
提示词(直接复制给 AI):
请用 TypeScript 写一个“rosbridge WebSocket 最小订阅”示例,要求:
1) 可配置 ws 地址(默认 ws://localhost:9090)
2) 连接成功后发送 subscribe:op/topic/type/id 必须齐全
3) onmessage 中做 JSON.parse,并根据 topic 路由处理
4) 至少打印 /chatter 的 msg.data
5) 必须包含 error/close 的处理日志
输出:完整代码 + 每段关键逻辑解释
校验点(你必须人工检查):
- subscribe 的 JSON 字段名是否写对(op/topic/type/id)
- 发送前是否
JSON.stringify - message 里是否先 parse 再判断字段(避免直接假设结构导致运行时报错)
如果把所有逻辑都写在一个组件里,通常会遇到:
- 订阅多个话题时,message 处理会变成一堆 if/else
- 断线/重连/取消订阅难以管理
msg结构不稳定,页面直接访问字段容易报错
export type RosbridgePublish<TMsg = unknown> = {
op: 'publish'
topic: string
msg: TMsg
}
export type RosbridgeSubscribe = {
op: 'subscribe'
topic: string
type: string
id: string
throttle_rate?: number
queue_length?: number
}
export type RosbridgeUnsubscribe = {
op: 'unsubscribe'
topic: string
id: string
}
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text)
} catch {
return null
}
}
function makeSubId(topic: string): string {
const normalized = topic.replace(/^\//, '').replace(/\//g, '-')
return `sub-${normalized}`
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export class RosbridgeClient {
private ws: WebSocket | null = null
private readonly handlers = new Map<string, (msg: unknown) => void>()
private readonly url: string
constructor(url: string) {
this.url = url
}
connect(): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) return
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) return
this.ws = new WebSocket(this.url)
this.ws.addEventListener('open', () => {
console.log('[rosbridge] connected:', this.url)
})
this.ws.addEventListener('message', (ev) => {
const raw = typeof ev.data === 'string' ? ev.data : ''
if (!raw) return
const data = safeJsonParse(raw)
if (!data || typeof data !== 'object') return
if (!('op' in data) || !('topic' in data) || !('msg' in data)) return
const d = data as { op: unknown; topic: unknown; msg: unknown }
if (d.op !== 'publish' || typeof d.topic !== 'string') return
const handler = this.handlers.get(d.topic)
if (!handler) return
handler(d.msg)
})
this.ws.addEventListener('error', () => {
console.error('[rosbridge] ws error')
})
this.ws.addEventListener('close', (ev) => {
console.warn('[rosbridge] closed:', ev.code, ev.reason)
})
}
async waitForOpen(timeoutMs = 5000): Promise<void> {
const start = Date.now()
while (true) {
const state = this.ws?.readyState
if (state === WebSocket.OPEN) return
if (state === WebSocket.CLOSING || state === WebSocket.CLOSED) {
throw new Error('WebSocket closed before OPEN')
}
if (Date.now() - start > timeoutMs) {
throw new Error(`Timeout waiting for WebSocket OPEN (${timeoutMs}ms)`)
}
await sleep(50)
}
}
disconnect(): void {
if (!this.ws) return
try {
this.ws.close()
} finally {
this.ws = null
this.handlers.clear()
}
}
subscribe(topic: string, type: string, handler: (msg: unknown) => void): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket is not OPEN. Call connect() and wait for open.')
}
const id = makeSubId(topic)
const req: RosbridgeSubscribe = { op: 'subscribe', topic, type, id }
this.ws.send(JSON.stringify(req))
this.handlers.set(topic, handler)
console.log('[rosbridge] subscribed:', req)
}
unsubscribe(topic: string): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
const id = makeSubId(topic)
const req: RosbridgeUnsubscribe = { op: 'unsubscribe', topic, id }
this.ws.send(JSON.stringify(req))
this.handlers.delete(topic)
console.log('[rosbridge] unsubscribed:', req)
}
}
handlers: Map<string, (msg) => void>:用 topic 做 key,把多话题的处理函数分发干净connect():只负责建立连接与事件监听,不做自动订阅(让订阅逻辑可控)message:只处理op=publish的推送,并把msg交给对应 topic handlersubscribe():发送 subscribe,并把 handler 存进 Map;订阅前强制要求OPEN,避免未就绪发送unsubscribe():按约定的订阅 id 发送取消订阅,并移除 handler(本示例会把开头的/去掉,并把路径分隔符/转为-生成 id,例如/chatter→sub-chatter)
2. 自检点(必须当场过一遍)
- 连接 open 后再 subscribe(否则会抛错或 silent fail)
- subscribe 里的
type字符串与 ROS2 消息一致(例如std_msgs/msg/String) - message 中只处理
op=publish,不要把其他 op 当成数据(避免误判)
本节的“格式转换”指两层:
unknown→ TS 接口(校验字段存在性与基本类型)- TS 接口 → 页面可用数据(例如挑关键字段、单位转换、数组统计等)
1. 示例 A:std_msgs/msg/String(/chatter)
export type StdStringMsg = { data: string }
export function toStdStringMsg(input: unknown): StdStringMsg | null {
if (!input || typeof input !== 'object') return null
if (!('data' in input)) return null
const data = (input as { data: unknown }).data
return typeof data === 'string' ? { data } : null
}
- 先判空与类型:
unknown不能直接当对象用 - 再检查字段:
'data' in input - 最后做类型收敛:只有
string才返回,否则返回null让上层决定如何降级
2. 示例 B:sensor_msgs/msg/LaserScan(传感器话题常见)
export type LaserScanMsg = {
angle_min: number
angle_max: number
angle_increment: number
ranges: number[]
}
export type LaserScanView = {
minRange: number
maxRange: number
validCount: number
}
export function toLaserScanMsg(input: unknown): LaserScanMsg | null {
if (!input || typeof input !== 'object') return null
const x = input as Record<string, unknown>
const angle_min = x.angle_min
const angle_max = x.angle_max
const angle_increment = x.angle_increment
const ranges = x.ranges
if (
typeof angle_min !== 'number' ||
typeof angle_max !== 'number' ||
typeof angle_increment !== 'number' ||
!Array.isArray(ranges)
) {
return null
}
const numericRanges = ranges.filter((v): v is number => typeof v === 'number')
return { angle_min, angle_max, angle_increment, ranges: numericRanges }
}
export function toLaserScanView(msg: LaserScanMsg): LaserScanView {
const finite = msg.ranges.filter((v) => Number.isFinite(v))
if (finite.length === 0) return { minRange: NaN, maxRange: NaN, validCount: 0 }
let minRange = finite[0]
let maxRange = finite[0]
for (const v of finite) {
if (v < minRange) minRange = v
if (v > maxRange) maxRange = v
}
return { minRange, maxRange, validCount: finite.length }
}
toLaserScanMsg:先做结构校验与类型收敛,确保 ranges 只保留数值toLaserScanView:把长数组转换成页面更友好的统计数据(min/max/数量)
参考实现:src/components/RosbridgeWorkshop.vue(可直接运行)
<template>
<div class="wrap">
<h2>项目工坊:多话题订阅与格式转换</h2>
<div class="row">
<label class="label" for="url">WebSocket URL</label>
<input id="url" v-model="url" class="input" type="text" />
</div>
<div class="row">
<div class="status">连接状态:{{ status }}</div>
<button class="btn" type="button" :disabled="status === 'OPEN' || status === 'CONNECTING'" @click="connect">
连接
</button>
<button class="btn" type="button" :disabled="status !== 'OPEN'" @click="disconnect">
断开
</button>
</div>
<div class="row">
<div class="status">/chatter:{{ chatterText ?? '(暂无)' }}</div>
<button class="btn" type="button" :disabled="status !== 'OPEN'" @click="toggleChatter">
{{ chatterSubscribed ? '取消订阅' : '订阅' }}
</button>
</div>
<div class="row">
<div class="status">
/scan(LaserScan)统计:min={{ scanView?.minRange ?? '—' }} max={{ scanView?.maxRange ?? '—' }}
count={{ scanView?.validCount ?? '—' }}
</div>
<button class="btn" type="button" :disabled="status !== 'OPEN'" @click="toggleScan">
{{ scanSubscribed ? '取消订阅' : '订阅' }}
</button>
</div>
<div v-if="lastError" class="row error">
错误:{{ lastError }}
</div>
</div>
</template>
<script setup lang="ts">
import { onUnmounted, ref } from 'vue'
import { RosbridgeClient } from '../utils/rosbridge'
import { toLaserScanMsg, toLaserScanView, toStdStringMsg, type LaserScanView } from '../utils/ros-msg-convert'
type Status = 'IDLE' | 'CONNECTING' | 'OPEN' | 'CLOSED'
const url = ref('ws://localhost:9090')
const status = ref<Status>('IDLE')
const lastError = ref('')
const chatterText = ref<string | null>(null)
const chatterSubscribed = ref(false)
const scanView = ref<LaserScanView | null>(null)
const scanSubscribed = ref(false)
let client: RosbridgeClient | null = null
async function connect(): Promise<void> {
lastError.value = ''
status.value = 'CONNECTING'
client = new RosbridgeClient(url.value)
client.connect()
try {
await client.waitForOpen(5000)
status.value = 'OPEN'
} catch (e) {
status.value = 'CLOSED'
lastError.value = e instanceof Error ? e.message : 'connect failed'
}
}
function disconnect(): void {
client?.disconnect()
client = null
status.value = 'CLOSED'
chatterSubscribed.value = false
scanSubscribed.value = false
}
function toggleChatter(): void {
if (!client) return
if (!chatterSubscribed.value) {
client.subscribe('/chatter', 'std_msgs/msg/String', (msg) => {
const parsed = toStdStringMsg(msg)
chatterText.value = parsed ? parsed.data : JSON.stringify(msg)
})
chatterSubscribed.value = true
return
}
client.unsubscribe('/chatter')
chatterSubscribed.value = false
}
function toggleScan(): void {
if (!client) return
if (!scanSubscribed.value) {
client.subscribe('/scan', 'sensor_msgs/msg/LaserScan', (msg) => {
const parsed = toLaserScanMsg(msg)
scanView.value = parsed ? toLaserScanView(parsed) : null
})
scanSubscribed.value = true
return
}
client.unsubscribe('/scan')
scanSubscribed.value = false
}
onUnmounted(() => {
disconnect()
})
</script>
<style scoped>
.wrap {
max-width: 860px;
margin: 24px auto;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 12px;
}
.row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.label {
width: 120px;
}
.input {
flex: 1;
padding: 8px 10px;
border: 1px solid #d1d5db;
border-radius: 8px;
}
.status {
flex: 1;
}
.btn {
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
}
.error {
color: #b91c1c;
}
</style>
-
connect():先创建RosbridgeClient,再await waitForOpen()确保连接就绪,避免“未就绪订阅”。 -
/chatter:用toStdStringMsg做最小校验后展示字符串。 -
实现连接:输入 ws 地址(默认
ws://localhost:9090),点击连接后状态可见 -
实现订阅:至少订阅 2 个话题,并能随时取消订阅
-
实现数据打印:打印原始
msg与转换后的结果(至少其中一个话题)
提示词(直接复制给 AI):
我要在 Vue3 + Vite + TypeScript 项目里订阅 ROS2 话题(通过 rosbridge WebSocket)。
请输出:
1) 一个 RosbridgeClient 封装(connect/disconnect/subscribe/unsubscribe)
2) subscribe 使用 rosbridge JSON 协议(op/topic/type/id),message 里只处理 op=publish
3) 示例:订阅 /chatter(std_msgs/msg/String),打印 msg.data
4) 可选:给一个 LaserScan(sensor_msgs/msg/LaserScan)的字段校验与转换示例(提取 ranges 的 min/max)
要求:代码可直接粘贴运行,并解释关键逻辑与常见坑。
校验点(你必须人工检查):
- 是否错误把
op=publish当成“我要发布”而不是“服务端推送” - 是否遗漏
JSON.stringify或遗漏 open 后再 subscribe - TS 类型是否过度自信(直接把 unknown 当成具体类型会导致运行时报错)
Markdown 与代码自检(提交前必做)
- 代码块语言标签完整(bash/json/ts),且三引号成对闭合
- subscribe/unsubscribe 的 JSON 字段名全部正确(op/topic/type/id)
- Web 端发送前必做
JSON.stringify,接收时先JSON.parse再校验字段 - TS 转换函数不直接信任 unknown,解析失败返回 null 并由上层降级处理